Skip to content

feat: doubao tts support streaming realtime audio - #2087

Merged
creamlike1024 merged 3 commits into
QuantumNous:mainfrom
feitianbubu:pr/doubao-tts-stream
Oct 22, 2025
Merged

feat: doubao tts support streaming realtime audio#2087
creamlike1024 merged 3 commits into
QuantumNous:mainfrom
feitianbubu:pr/doubao-tts-stream

Conversation

@feitianbubu

@feitianbubu feitianbubu commented Oct 22, 2025

Copy link
Copy Markdown
Member

豆包语音支持流式实时音频

Summary by CodeRabbit

  • New Features
    • Volcengine TTS now supports streaming audio delivery over WebSocket with chunked HTTP responses and authorization handling.
    • Added a binary WebSocket messaging protocol layer to enable robust framed RPC-style communication for audio streaming while preserving existing non-streaming behavior.

@coderabbitai

coderabbitai Bot commented Oct 22, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds WebSocket-based streaming support for Volcengine TTS: a new binary message protocol, adaptor changes to route "submit" TTS requests to WS and mark streaming, and a WS response handler that dials, exchanges framed messages, and streams audio chunks back to clients.

Changes

Cohort / File(s) Change Summary
Protocol layer
relay/channel/volcengine/protocols.go
New file implementing a binary WebSocket message protocol: types (EventType, MsgType, flags), Message struct, marshal/unmarshal, framing helpers, and convenience WS RPC functions (Start/Finish Connection/Session, TaskRequest, ReceiveMessage, WaitForEvent, etc.).
Adaptor routing
relay/channel/volcengine/adaptor.go
Added unexported context keys, switched TTS operation handling (use "submit" to enable streaming), store Volcengine TTS request in context when streaming, mark relay as streaming, switch WS endpoint URL for audio requests, and bypass HTTP request when streaming.
TTS WS handler
relay/channel/volcengine/tts.go
Added handleTTSWebSocketResponse: authenticates via API key, dials WebSocket, sends framed TTS request, receives Message frames, streams audio chunks with proper Content-Type and chunked transfer, handles error/termination events and usage metrics; preserves existing HTTP response path for non-streaming.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Adapter
    participant VolcWS as Volcengine WS
    Note right of Adapter #e6f7ff: Adaptor routes "submit" -> streaming
    Client->>Adapter: TTS request (operation="submit")
    Adapter->>Adapter: save TTS request in context\nset IsStream=true
    Adapter->>VolcWS: Dial WS (Authorization header)
    VolcWS-->>Adapter: WS connection established
    Adapter->>VolcWS: Send framed TTS request (binary)
    loop streaming audio
        VolcWS-->>Adapter: Message (audio chunk / event)
        Adapter-->>Client: Chunked HTTP response (audio bytes)
    end
    VolcWS-->>Adapter: End event (sequence negative)
    Adapter->>Client: Close/finish response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • creamlike1024

Poem

🐇 I hopped into code with a twitch of my nose,
Frames clacked like carrots in binary rows,
I nibbled a socket, then streamed you a tune,
Chunks hopping out beneath a bright moon.
Hooray — voices sprout from this rabbit’s small rune!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The PR title "feat: doubao tts support streaming realtime audio" directly corresponds to the main changes in the pull request. All three modified files (adaptor.go, protocols.go, and tts.go) are focused on implementing WebSocket-based streaming support for Doubao TTS audio via the VolcEngine channel. The title is specific, identifying both the service (Doubao TTS) and the feature being added (streaming realtime audio), and avoids vague language or noise. It clearly summarizes the primary objective without being misleading.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
relay/channel/volcengine/tts.go (1)

146-155: Close response body on all paths.

Defer Close before ReadAll to avoid leaks when ReadAll errors.

Apply this diff:

-    body, readErr := io.ReadAll(resp.Body)
-    if readErr != nil {
+    defer resp.Body.Close()
+    body, readErr := io.ReadAll(resp.Body)
+    if readErr != nil {
         return nil, types.NewErrorWithStatusCode(
             errors.New("failed to read volcengine response"),
             types.ErrorCodeReadResponseBodyFailed,
             http.StatusInternalServerError,
         )
     }
-    defer resp.Body.Close()
relay/channel/volcengine/adaptor.go (1)

299-305: Fix Authorization header format: use "Bearer " not "Bearer;"

The Volcengine Ark /v1/audio/speech API requires the standard format "Authorization: Bearer " with a space. The current code at lines 299-305 incorrectly uses a semicolon:

req.Set("Authorization", "Bearer;"+parts[1])

Change to:

req.Set("Authorization", "Bearer "+parts[1])

Standard authorization headers use "Bearer token" with a space, not a semicolon. This will cause authentication failures against the Volcengine endpoint.

🧹 Nitpick comments (7)
relay/channel/volcengine/tts.go (3)

216-216: Use request context and a handshake timeout for WS dial.

Avoid context.Background(); honor client cancellation and set a sane handshake timeout.

Apply this diff:

+    dialer := *websocket.DefaultDialer
+    dialer.HandshakeTimeout = 10 * time.Second
-    conn, resp, dialErr := websocket.DefaultDialer.DialContext(context.Background(), requestURL, header)
+    conn, resp, dialErr := dialer.DialContext(c.Request.Context(), requestURL, header)

And add import:

 import (
     "context"
@@
     "github.com/gorilla/websocket"
+    "time"
 )

294-302: End-of-stream detection: also honor the negative-seq flag.

Guard on MsgTypeFlagNegativeSeq (bitmask) in addition to Sequence<0 to be robust across servers.

Apply this diff:

-            if msg.Sequence < 0 {
+            if (msg.MsgTypeFlag&MsgTypeFlagNegativeSeq) != 0 || msg.Sequence < 0 {
                 c.Status(http.StatusOK)
                 usage = &dto.Usage{
                     PromptTokens:     info.PromptTokens,
                     CompletionTokens: 0,
                     TotalTokens:      info.PromptTokens,
                 }
                 return usage, nil
             }

251-254: Consider disabling proxy buffering for streaming.

To improve low-latency playback behind Nginx, add X-Accel-Buffering: no.

Apply this diff:

     contentType := getContentTypeByEncoding(encoding)
     c.Header("Content-Type", contentType)
+    c.Header("X-Accel-Buffering", "no")
relay/channel/volcengine/adaptor.go (1)

59-59: Optional: Avoid generic string keys for Gin context.

Use a typed key or a unique package-prefixed string to avoid collisions.

Example:

type ctxKey string
const (
    contextKeyTTSRequest     ctxKey = "volcengine/tts_request"
    contextKeyResponseFormat ctxKey = "volcengine/response_format"
)
relay/channel/volcengine/protocols.go (3)

406-411: Simpler trailing-bytes check.

Avoid reading an extra byte; just check remaining length.

Apply this diff:

-    if _, err := buf.ReadByte(); err != io.EOF {
-        return fmt.Errorf("unexpected data after message: %v", err)
-    }
+    if buf.Len() != 0 {
+        return fmt.Errorf("unexpected %d bytes after message", buf.Len())
+    }

580-593: WaitForEvent should loop until the expected event arrives.

Current logic errors on first non-matching message.

Apply this diff:

 func WaitForEvent(conn *websocket.Conn, msgType MsgType, eventType EventType) (*Message, error) {
     for {
         msg, err := ReceiveMessage(conn)
         if err != nil {
             return nil, err
         }
-        if msg.MsgType != msgType || msg.EventType != eventType {
-            return nil, fmt.Errorf("unexpected message: %s", msg)
-        }
-        if msg.MsgType == msgType && msg.EventType == eventType {
+        if msg.MsgType == msgType && msg.EventType == eventType {
             return msg, nil
         }
+        // ignore unrelated messages and continue waiting
     }
 }

569-571: Restrict to binary frames or tolerate text gracefully.

If the server never sends text, reject text frames; otherwise, ignore them instead of attempting to parse.

Example:

if mt != websocket.BinaryMessage {
    continue // or: return nil, fmt.Errorf("unexpected message type: %d", mt)
}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4661399 and 431b3a8.

📒 Files selected for processing (3)
  • relay/channel/volcengine/adaptor.go (6 hunks)
  • relay/channel/volcengine/protocols.go (1 hunks)
  • relay/channel/volcengine/tts.go (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
relay/channel/volcengine/tts.go (4)
relay/common/relay_info.go (1)
  • RelayInfo (75-122)
types/error.go (7)
  • NewAPIError (87-95)
  • NewErrorWithStatusCode (259-275)
  • ErrorCodeChannelInvalidKey (57-57)
  • ErrorCodeBadResponseStatusCode (70-70)
  • ErrorCodeBadRequestBody (66-66)
  • ErrorCodeBadResponse (71-71)
  • ErrorCode (36-36)
relay/channel/volcengine/protocols.go (6)
  • FullClientRequest (595-607)
  • ReceiveMessage (564-578)
  • MsgType (18-18)
  • MsgTypeError (74-74)
  • MsgTypeFrontEndResultServer (73-73)
  • MsgTypeAudioOnlyServer (72-72)
dto/openai_response.go (1)
  • Usage (222-235)
relay/channel/volcengine/adaptor.go (5)
constant/channel.go (2)
  • ChannelBaseURLs (60-117)
  • ChannelTypeVolcEngine (45-45)
relay/channel/api_request.go (1)
  • DoApiRequest (61-90)
relay/common/relay_info.go (1)
  • RelayInfo (75-122)
types/error.go (3)
  • NewAPIError (87-95)
  • NewErrorWithStatusCode (259-275)
  • ErrorCodeBadRequestBody (66-66)
relay/channel/volcengine/tts.go (1)
  • VolcengineTTSRequest (21-26)
🔇 Additional comments (1)
relay/channel/volcengine/tts.go (1)

213-217: Fix Authorization header format and verify success code for WebSocket protocol.

The Authorization header is missing a space after the semicolon. The spec requires "Bearer; {token}", but line 213 generates "Bearer;%s" without the space.

Additionally, the success code in JSON responses should be 20000000, but line 165 checks for Code != 3000. However, line 165 is in the HTTP TTS flow, not the WebSocket flow shown at lines 213-217. Verify whether the WebSocket protocol uses a different success code validation mechanism or if HTTP TTS also needs updating to 20000000.

Comment thread relay/channel/volcengine/adaptor.go Outdated
Comment thread relay/channel/volcengine/adaptor.go
Comment on lines +356 to +386
encoding := mapEncoding(c.GetString(contextKeyResponseFormat))
if info.IsStream {
volcRequestInterface, exists := c.Get(contextKeyTTSRequest)
if !exists {
return nil, types.NewErrorWithStatusCode(
errors.New("volcengine TTS request not found in context"),
types.ErrorCodeBadRequestBody,
http.StatusInternalServerError,
)
}

volcRequest, ok := volcRequestInterface.(VolcengineTTSRequest)
if !ok {
return nil, types.NewErrorWithStatusCode(
errors.New("invalid volcengine TTS request type"),
types.ErrorCodeBadRequestBody,
http.StatusInternalServerError,
)
}

// Get the WebSocket URL
requestURL, urlErr := a.GetRequestURL(info)
if urlErr != nil {
return nil, types.NewErrorWithStatusCode(
urlErr,
types.ErrorCodeBadRequestBody,
http.StatusInternalServerError,
)
}
return handleTTSWebSocketResponse(c, requestURL, volcRequest, info, encoding)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Bug: encoding is re-mapped, breaking "opus" to "mp3".

You store the already-mapped encoding in context, then map it again here. For "ogg_opus" this returns "mp3", causing wrong Content-Type.

Apply this diff:

-        encoding := mapEncoding(c.GetString(contextKeyResponseFormat))
+        encoding := c.GetString(contextKeyResponseFormat)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
encoding := mapEncoding(c.GetString(contextKeyResponseFormat))
if info.IsStream {
volcRequestInterface, exists := c.Get(contextKeyTTSRequest)
if !exists {
return nil, types.NewErrorWithStatusCode(
errors.New("volcengine TTS request not found in context"),
types.ErrorCodeBadRequestBody,
http.StatusInternalServerError,
)
}
volcRequest, ok := volcRequestInterface.(VolcengineTTSRequest)
if !ok {
return nil, types.NewErrorWithStatusCode(
errors.New("invalid volcengine TTS request type"),
types.ErrorCodeBadRequestBody,
http.StatusInternalServerError,
)
}
// Get the WebSocket URL
requestURL, urlErr := a.GetRequestURL(info)
if urlErr != nil {
return nil, types.NewErrorWithStatusCode(
urlErr,
types.ErrorCodeBadRequestBody,
http.StatusInternalServerError,
)
}
return handleTTSWebSocketResponse(c, requestURL, volcRequest, info, encoding)
}
encoding := c.GetString(contextKeyResponseFormat)
if info.IsStream {
volcRequestInterface, exists := c.Get(contextKeyTTSRequest)
if !exists {
return nil, types.NewErrorWithStatusCode(
errors.New("volcengine TTS request not found in context"),
types.ErrorCodeBadRequestBody,
http.StatusInternalServerError,
)
}
volcRequest, ok := volcRequestInterface.(VolcengineTTSRequest)
if !ok {
return nil, types.NewErrorWithStatusCode(
errors.New("invalid volcengine TTS request type"),
types.ErrorCodeBadRequestBody,
http.StatusInternalServerError,
)
}
// Get the WebSocket URL
requestURL, urlErr := a.GetRequestURL(info)
if urlErr != nil {
return nil, types.NewErrorWithStatusCode(
urlErr,
types.ErrorCodeBadRequestBody,
http.StatusInternalServerError,
)
}
return handleTTSWebSocketResponse(c, requestURL, volcRequest, info, encoding)
}
🤖 Prompt for AI Agents
In relay/channel/volcengine/adaptor.go around lines 356 to 386, the code re-maps
an encoding already stored in the request context which transforms "ogg_opus"
into "mp3" and causes an incorrect Content-Type; fix by not re-mapping the
encoding there — read the encoding value from context (or use the already-mapped
value stored earlier) and pass it through to handleTTSWebSocketResponse
unchanged, or only map if no mapped value exists in context.

Comment on lines +374 to +378
_, err = buf.ReadByte()
if err != nil {
return err
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unmarshal ignores MsgType and Flags from header.

Parse and set them from the second header byte for robustness.

Apply this diff:

-    _, err = buf.ReadByte()
-    if err != nil {
-        return err
-    }
+    typeAndFlag, err := buf.ReadByte()
+    if err != nil {
+        return err
+    }
+    m.MsgType = MsgType(typeAndFlag >> 4)
+    m.MsgTypeFlag = MsgTypeFlagBits(typeAndFlag & 0b00001111)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_, err = buf.ReadByte()
if err != nil {
return err
}
typeAndFlag, err := buf.ReadByte()
if err != nil {
return err
}
m.MsgType = MsgType(typeAndFlag >> 4)
m.MsgTypeFlag = MsgTypeFlagBits(typeAndFlag & 0b00001111)

Comment on lines +379 to +386
serializationCompression, err := buf.ReadByte()
if err != nil {
return err
}

m.Serialization = SerializationBits(serializationCompression & 0b11110000)
m.Compression = CompressionBits(serializationCompression & 0b00001111)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Bug: serialization/compression nibbles not shifted.

High 4 bits must be right-shifted; current code stores 0x10 etc instead of 0x1.

Apply this diff:

-    m.Serialization = SerializationBits(serializationCompression & 0b11110000)
-    m.Compression = CompressionBits(serializationCompression & 0b00001111)
+    m.Serialization = SerializationBits((serializationCompression >> 4) & 0b00001111)
+    m.Compression = CompressionBits(serializationCompression & 0b00001111)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
serializationCompression, err := buf.ReadByte()
if err != nil {
return err
}
m.Serialization = SerializationBits(serializationCompression & 0b11110000)
m.Compression = CompressionBits(serializationCompression & 0b00001111)
serializationCompression, err := buf.ReadByte()
if err != nil {
return err
}
m.Serialization = SerializationBits((serializationCompression >> 4) & 0b00001111)
m.Compression = CompressionBits(serializationCompression & 0b00001111)
🤖 Prompt for AI Agents
In relay/channel/volcengine/protocols.go around lines 379 to 386, the code masks
the high nibble for Serialization but doesn't right-shift it, so values like
0x10 are stored instead of 0x1; change the assignment to right-shift the high 4
bits by 4 when setting m.Serialization and keep the low 4 bits for m.Compression
(i.e., mask 0xF0 then >>4 for Serialization, mask 0x0F for Compression),
ensuring proper casting to the SerializationBits and CompressionBits types.

Comment thread relay/channel/volcengine/protocols.go
Comment on lines +551 to +562
func (m *Message) readPayload(buf *bytes.Buffer) error {
var size uint32
if err := binary.Read(buf, binary.BigEndian, &size); err != nil {
return err
}

if size > 0 {
m.Payload = buf.Next(int(size))
}

return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate payload length exactly to catch truncation.

Ensure Next() returns the expected size; otherwise error.

Apply this diff:

-    if size > 0 {
-        m.Payload = buf.Next(int(size))
-    }
+    if size > 0 {
+        m.Payload = buf.Next(int(size))
+        if uint32(len(m.Payload)) != size {
+            return fmt.Errorf("payload truncated: expected %d, got %d", size, len(m.Payload))
+        }
+    }
🤖 Prompt for AI Agents
In relay/channel/volcengine/protocols.go around lines 551 to 562, the
readPayload function must validate that the buffer contains exactly the
requested payload length to detect truncation; before calling
buf.Next(int(size)), check that buf.Len() >= int(size) (and optionally that size
is within a sane maximum), then call buf.Next and verify the returned slice
length equals int(size); if not, return a descriptive error indicating truncated
payload. Ensure you still set m.Payload when successful and propagate any read
error.

Comment on lines +253 to +255
c.Header("Content-Type", contentType)
c.Header("Transfer-Encoding", "chunked")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t set Transfer-Encoding manually; let net/http handle chunking.

Setting "Transfer-Encoding: chunked" can conflict with Go’s HTTP/2 and proxy behavior. Remove it and rely on Flush().

Apply this diff:

-    c.Header("Transfer-Encoding", "chunked")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
c.Header("Content-Type", contentType)
c.Header("Transfer-Encoding", "chunked")
c.Header("Content-Type", contentType)
🤖 Prompt for AI Agents
In relay/channel/volcengine/tts.go around lines 253 to 255, remove the manual
c.Header("Transfer-Encoding", "chunked") call; do not set Transfer-Encoding
yourself because Go's net/http (and HTTP/2/proxies) manages chunking. Keep the
Content-Type header, ensure the response writer implements http.Flusher and you
call Flush() where appropriate to stream data, and delete the Transfer-Encoding
header line so chunking is handled by the net/http server.

Comment thread relay/channel/volcengine/tts.go Outdated
Comment thread relay/channel/volcengine/protocols.go Outdated
Comment on lines +246 to +264
// 0 1 2 3
// | 0 1 2 3 4 5 6 7 | 0 1 2 3 4 5 6 7 | 0 1 2 3 4 5 6 7 | 0 1 2 3 4 5 6 7 |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | Version | Header Size | Msg Type | Flags |
// | (4 bits) | (4 bits) | (4 bits) | (4 bits) |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | Serialization | Compression | Reserved |
// | (4 bits) | (4 bits) | (8 bits) |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | |
// | Optional Header Extensions |
// | (if Header Size > 1) |
// | |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | |
// | Payload |
// | (variable length) |
// | |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

注释的二进制位没对上,可以修改一下

Comment on lines +384 to +385
m.Serialization = SerializationBits(serializationCompression & 0b11110000)
m.Compression = CompressionBits(serializationCompression & 0b00001111)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m.Serialization 好像没有用上,如果不需要的话可以不解析,如果解析的话还是移位后再存储好一些

Comment thread relay/channel/volcengine/tts.go Outdated
continue
case MsgTypeAudioOnlyServer:
if len(msg.Payload) > 0 {
audioBuffer = append(audioBuffer, msg.Payload...)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

audioBuffer 没有用上

Comment thread relay/channel/volcengine/protocols.go Outdated
Comment on lines +57 to +58
SerializationThrift SerializationBits = 0b11
SerializationCustom SerializationBits = 0b1111

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SerializationThrift 和 SerializationCustom 我没有在火山的文档中找到,这个是在哪里定义的

@feitianbubu

Copy link
Copy Markdown
Member Author

重构删除无用代码

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (9)
relay/channel/volcengine/adaptor.go (3)

44-101: Critical: Streaming enabled for non-official endpoints.

Line 92 sets info.IsStream = true solely based on Operation == "submit", but Line 262 only returns a WebSocket URL for the official Volcengine base URL. If a user configures a custom HTTP base URL and submits with operation: "submit", DoRequest (Line 320) will bypass the HTTP request, and DoResponse (Line 331) will attempt WebSocket operations on an HTTP endpoint, causing failures.

Apply this diff to gate streaming by the official endpoint:

     c.Set(contextKeyTTSRequest, volcRequest)

-    if volcRequest.Request.Operation == "submit" {
+    baseUrl := info.ChannelBaseUrl
+    if baseUrl == "" {
+        baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine]
+    }
+    if baseUrl == channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine] &&
+        volcRequest.Request.Operation == "submit" {
         info.IsStream = true
     }

328-362: Critical: Double-mapping encoding corrupts format.

Line 330 calls mapEncoding(c.GetString(contextKeyResponseFormat)) on an already-mapped encoding stored at Line 58. For example, if the user requests "opus", Line 56 maps it to "ogg_opus" and stores it in context. Line 330 then maps "ogg_opus" again, which returns "mp3" (since "ogg_opus" isn't a key in responseFormatToEncodingMap), causing the wrong Content-Type header.

Apply this diff to use the already-mapped encoding:

-        encoding := mapEncoding(c.GetString(contextKeyResponseFormat))
+        encoding := c.GetString(contextKeyResponseFormat)

330-361: Major: DoResponse should validate base URL for WebSocket.

Lines 331-360 attempt WebSocket operations when info.IsStream is true, but if info.IsStream was incorrectly set (due to the issue at Line 92), this will fail for non-official endpoints. Even after fixing Line 92, adding a defensive check here improves robustness.

Apply this diff to add a guard:

         encoding := c.GetString(contextKeyResponseFormat)
         if info.IsStream {
+            baseUrl := info.ChannelBaseUrl
+            if baseUrl == "" {
+                baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine]
+            }
+            // Only use WebSocket for official Volcengine endpoint
+            if baseUrl != channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine] {
+                return handleTTSResponse(c, resp, info, encoding)
+            }
             volcRequestInterface, exists := c.Get(contextKeyTTSRequest)
relay/channel/volcengine/protocols.go (5)

317-321: Critical: MsgType and Flags discarded during unmarshal.

Line 317 reads the second header byte containing MsgType and MsgTypeFlag but discards it with _. This contradicts Line 233 in NewMessageFromBytes, which correctly extracts these fields. The result is that Unmarshal leaves m.MsgType and m.MsgTypeFlag at their zero values, breaking message parsing.

Apply this diff to parse and store both fields:

-    _, err = buf.ReadByte()
-    if err != nil {
-        return err
-    }
+    typeAndFlag, err := buf.ReadByte()
+    if err != nil {
+        return err
+    }
+    m.MsgType = MsgType(typeAndFlag >> 4)
+    m.MsgTypeFlag = MsgTypeFlagBits(typeAndFlag & 0b00001111)

322-329: Critical: Serialization field stores unshifted nibble.

Line 327 masks the high nibble (0b11110000) but doesn't right-shift it, so m.Serialization will hold 0x10 instead of 0x1 for SerializationJSON. This breaks serialization type checks and marshaling round-trips.

Apply this diff to shift the high nibble into the low 4 bits:

-    m.Serialization = SerializationBits(serializationCompression & 0b11110000)
-    m.Compression = CompressionBits(serializationCompression & 0b00001111)
+    m.Serialization = SerializationBits((serializationCompression >> 4) & 0b00001111)
+    m.Compression = CompressionBits(serializationCompression & 0b00001111)

356-374: Critical: Flag checks use equality; combined flags fail.

Lines 357, 363 use equality (==) to test MsgTypeFlagWithEvent and sequence flags. When flags are combined (e.g., event + positive sequence = 0b101), equality checks fail and necessary fields are omitted from marshaling. The same issue affects Lines 422-440 in readers().

Apply this diff to use bitmask checks:

-    if m.MsgTypeFlag == MsgTypeFlagWithEvent {
+    if (m.MsgTypeFlag & MsgTypeFlagWithEvent) != 0 {
         writers = append(writers, m.writeEvent, m.writeSessionID)
     }
@@
-        if m.MsgTypeFlag == MsgTypeFlagPositiveSeq || m.MsgTypeFlag == MsgTypeFlagNegativeSeq {
+        if (m.MsgTypeFlag & (MsgTypeFlagPositiveSeq | MsgTypeFlagNegativeSeq)) != 0 {
             writers = append(writers, m.writeSequence)
         }

Apply the same bitmask logic in readers() at Lines 425, 434:

-        if m.MsgTypeFlag == MsgTypeFlagPositiveSeq || m.MsgTypeFlag == MsgTypeFlagNegativeSeq {
+        if (m.MsgTypeFlag & (MsgTypeFlagPositiveSeq | MsgTypeFlagNegativeSeq)) != 0 {
             readers = append(readers, m.readSequence)
         }
@@
-    if m.MsgTypeFlag == MsgTypeFlagWithEvent {
+    if (m.MsgTypeFlag & MsgTypeFlagWithEvent) != 0 {
         readers = append(readers, m.readEvent, m.readSessionID, m.readConnectID)
     }

256-272: Major: String() equality checks break with combined flags.

Lines 259, 266 use == to check for sequence flags in String(), so messages with combined flags (event + sequence) won't display the sequence.

Apply this diff:

-        if m.MsgTypeFlag == MsgTypeFlagPositiveSeq || m.MsgTypeFlag == MsgTypeFlagNegativeSeq {
+        if (m.MsgTypeFlag & (MsgTypeFlagPositiveSeq | MsgTypeFlagNegativeSeq)) != 0 {
             return fmt.Sprintf("%s, %s, Sequence: %d, PayloadSize: %d", m.MsgType, m.EventType, m.Sequence, len(m.Payload))
         }
@@
-        if m.MsgTypeFlag == MsgTypeFlagPositiveSeq || m.MsgTypeFlag == MsgTypeFlagNegativeSeq {
+        if (m.MsgTypeFlag & (MsgTypeFlagPositiveSeq | MsgTypeFlagNegativeSeq)) != 0 {
             return fmt.Sprintf("%s, %s, Sequence: %d, Payload: %s",
                 m.MsgType, m.EventType, m.Sequence, string(m.Payload))
         }

494-505: Major: Payload truncation not validated.

Line 501 calls buf.Next(int(size)) but doesn't confirm the returned slice length matches size. If the buffer is shorter, m.Payload will be truncated without error, causing silent data loss.

Apply this diff to detect truncation:

     if size > 0 {
         m.Payload = buf.Next(int(size))
+        if uint32(len(m.Payload)) != size {
+            return fmt.Errorf("payload truncated: expected %d, got %d", size, len(m.Payload))
+        }
     }
relay/channel/volcengine/tts.go (1)

246-249: Major: Remove manual Transfer-Encoding header.

Line 248 manually sets "Transfer-Encoding: chunked", which conflicts with Go's net/http automatic chunking and can break HTTP/2 or proxies. The Flush() calls at Line 281 are sufficient for streaming.

Apply this diff:

     contentType := getContentTypeByEncoding(encoding)
     c.Header("Content-Type", contentType)
-    c.Header("Transfer-Encoding", "chunked")
🧹 Nitpick comments (3)
relay/channel/volcengine/protocols.go (2)

459-462: Recommended: Validate SessionID length to catch truncation.

Line 460 calls buf.Next(int(size)) without verifying the returned slice length. If the buffer is short, the session ID will be silently truncated.

Apply this diff:

     if size > 0 {
         m.SessionID = string(buf.Next(int(size)))
+        if uint32(len(m.SessionID)) != size {
+            return fmt.Errorf("session ID truncated: expected %d, got %d", size, len(m.SessionID))
+        }
     }

479-482: Recommended: Validate ConnectID length to catch truncation.

Line 480 calls buf.Next(int(size)) without checking the returned slice length, risking silent truncation.

Apply this diff:

     if size > 0 {
         m.ConnectID = string(buf.Next(int(size)))
+        if uint32(len(m.ConnectID)) != size {
+            return fmt.Errorf("connect ID truncated: expected %d, got %d", size, len(m.ConnectID))
+        }
     }
relay/channel/volcengine/tts.go (1)

298-305: Optional: Deduplicate usage object creation.

Lines 286-291 and 299-303 create identical usage objects. Consider extracting this into a helper or setting it once after the loop.

For example:

func createTTSUsage(info *relaycommon.RelayInfo) *dto.Usage {
    return &dto.Usage{
        PromptTokens:     info.PromptTokens,
        CompletionTokens: 0,
        TotalTokens:      info.PromptTokens,
    }
}

Then replace both instances with usage = createTTSUsage(info).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 431b3a8 and bf66bbe.

📒 Files selected for processing (3)
  • relay/channel/volcengine/adaptor.go (8 hunks)
  • relay/channel/volcengine/protocols.go (1 hunks)
  • relay/channel/volcengine/tts.go (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
relay/channel/volcengine/tts.go (4)
relay/common/relay_info.go (1)
  • RelayInfo (75-122)
types/error.go (7)
  • NewAPIError (87-95)
  • NewErrorWithStatusCode (259-275)
  • ErrorCodeChannelInvalidKey (57-57)
  • ErrorCodeBadResponseStatusCode (70-70)
  • ErrorCodeBadRequestBody (66-66)
  • ErrorCodeBadResponse (71-71)
  • ErrorCode (36-36)
relay/channel/volcengine/protocols.go (6)
  • FullClientRequest (522-533)
  • ReceiveMessage (507-520)
  • MsgType (15-15)
  • MsgTypeError (52-52)
  • MsgTypeFrontEndResultServer (51-51)
  • MsgTypeAudioOnlyServer (50-50)
dto/openai_response.go (1)
  • Usage (222-235)
relay/channel/volcengine/adaptor.go (5)
constant/channel.go (2)
  • ChannelBaseURLs (60-117)
  • ChannelTypeVolcEngine (45-45)
relay/channel/api_request.go (1)
  • DoApiRequest (61-90)
relay/common/relay_info.go (1)
  • RelayInfo (75-122)
types/error.go (3)
  • NewAPIError (87-95)
  • NewErrorWithStatusCode (259-275)
  • ErrorCodeBadRequestBody (66-66)
relay/channel/volcengine/tts.go (1)
  • VolcengineTTSRequest (21-26)
🔇 Additional comments (8)
relay/channel/volcengine/tts.go (5)

199-207: LGTM: Clean error handling for auth parsing.

The function properly parses the API key and returns a well-structured error with the correct status code for authentication failures.


209-227: LGTM: Robust WebSocket dial with proper error handling.

The dial logic correctly sets the Authorization header, handles both connection errors and HTTP response errors, and provides appropriate error codes for each case.


229-244: LGTM: Request marshaling and sending handled correctly.

Proper error handling for JSON marshaling and WebSocket send operations with appropriate error codes.


250-261: LGTM: Receive loop with proper close detection.

The loop correctly handles WebSocket close events (normal closure and going away) and returns appropriate errors for other failures.


263-295: LGTM: Message type handling and streaming logic are correct.

The switch properly handles error messages, skips frontend result messages, streams audio chunks with flush, and terminates on negative sequence. Usage metrics are set correctly.

relay/channel/volcengine/adaptor.go (3)

26-29: LGTM: Context keys properly scoped.

The unexported context keys follow Go conventions and avoid collisions.


260-264: LGTM: WebSocket URL correctly gated by official endpoint.

Line 261 checks if the base URL matches the official Volcengine endpoint before returning the WebSocket URL, ensuring custom HTTP endpoints fall back to the HTTP path at Line 264.


312-326: LGTM: DoRequest bypasses HTTP for official streaming.

Lines 313-324 correctly skip the HTTP request when streaming is enabled and the base URL is official, aligning with the WebSocket flow in DoResponse.

@creamlike1024
creamlike1024 merged commit b99099f into QuantumNous:main Oct 22, 2025
1 check passed
ennnnny pushed a commit to ennnnny/new-api that referenced this pull request Mar 17, 2026
…ream

feat: doubao tts support streaming realtime audio
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants